fix(native): cut circular variable resolution instead of blowing the stack - #422
Open
YevheniiKotyrlo wants to merge 2 commits into
Open
fix(native): cut circular variable resolution instead of blowing the stack#422YevheniiKotyrlo wants to merge 2 commits into
YevheniiKotyrlo wants to merge 2 commits into
Conversation
…stack
A variable is handed to a descendant as an UNRESOLVED descriptor, so a value
that names its own variable resolves back into itself. Each of these takes the
render down with `RangeError: Maximum call stack size exceeded`:
.parent { --a: red } .mid { --a: var(--a) } .child { color: var(--a) }
.parent { --a: red } .mid { --a: var(--nope, var(--a)) } .child { color: var(--a) }
.parent { --a: red } .mid { --a: var(--b); --b: var(--a) } .child { color: var(--a) }
`varResolver` carried a `variableHistory` guard, but it could never fire. The
set was destructured out of `options` with a `new Set()` default and never
written back, so every invocation built its own empty one and the recursion
never shared a history. The registration also sat AFTER the
`if (name in variables)` early return — which is the branch a descendant takes,
and therefore the branch the recursion runs through.
The set now lives on `options`, so every nested resolve sees it, and a name is
registered before any of its values are resolved. It is released in a `finally`
once they are, which makes it a resolution STACK rather than a visited set: a
genuine cycle is cut on re-entry, while a name read twice in one declaration
(`box-shadow: var(--c) 1px 1px, var(--c) 2px 2px`) still resolves both times.
Every row in the census asserted `{}`, which is what the cycle guard produces
AND what a dead variable resolver produces. Making `varResolver` return
`undefined` unconditionally — every `var()` in the library dead — reddens 143
tests across the suite and left all three rows green. Each row now reads a
non-cyclic `--unrelated` beside the cycle, so a row can only pass while
resolution still works.
The rows also did not compile to the shapes they described. A variable
declared exactly once is substituted into its readers, so
`.mid { --a: var(--b); --b: var(--a) }` folded to `.mid { --a: var(--a) }` and
compiled to the same stylesheet as the first row — the census advertised three
shapes and delivered two. Every name in a cycle is now declared twice, which is
what makes the two-node row a two-node cycle.
Two mutations of the guard survived the census and no longer do:
- Returning the re-entering reference's fallback from the cut, against the
spec sentence the guard quotes. The fallback sat on the OUTER `var()`, so
the cut had none to return and the mutation was a no-op; it now sits on the
reference that re-enters.
- Emptying the whole stack in the `finally` rather than popping one frame. A
name re-entered from two branches of ONE value separates those, and no test
had that shape: `--a: var(--b) var(--c)` where both name `--a` recurses
forever under `clear()`. Removal is now pinned from both sides — removing
too little reddens the two reads in one `box-shadow`, too much reddens the
diamond.
Both public entry points that recurse without the guard get a test —
`useUnstableNativeVariable` and `VariableContextProvider`, whose value type
admits a `var()` reference. So does the compiler's own cycle guard, which
nothing covered: disabling `flattenVar`'s `seen` set leaves the suite at the
exact baseline while `.solo { --z: var(--z) }` recurses at compile time.
`ResolveValueOptions.variableHistory` becomes `namesBeingResolved`, matching
the local it feeds and what it holds — the names whose resolution is in
progress, not the names already seen. The type is internal to `native/styles/`
and is re-exported from no entry point.
The comment on the `finally` had `options` threaded through the whole style
calculation, which would refuse a variable read by a second declaration.
`applyDeclarations` builds a fresh options object per declaration, so two
declarations never share a stack; removing the `finally` reddens exactly one
test in the suite, the two reads in one `box-shadow`. The comments now also
record what the cut produces — the property loses its value, or keeps a
truncated one where the cycle is part of a larger value — that an inherited
name resolving to nothing swallows a reader's fallback, and that the guard
bounds cycles only: a long enough non-circular chain still exhausts the stack,
at a depth that varies with how deep it already is.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
CSS Variables Level 1 §3 is explicit about reference cycles: "if there is a cycle in the dependency graph, all the custom properties in the cycle must compute to their guaranteed-invalid value" — which makes the consuming declaration invalid at computed-value time and leaves the property unset. A well-defined, non-fatal outcome.
The native runtime instead recurses until the JS stack is exhausted and throws out of
render. Measured onmain(f70c402) through@testing-library/react-native:In an app that reaches whatever error boundary sits above the tree, so one cyclic custom property replaces a screen with a fallback.
Why it is reachable from ordinary CSS
The compiler has a working cycle guard of its own —
flattenVar'sseenset in theinlineVariablespass — which is why this is not fired by every stylesheet. But that pass only folds a custom property declared exactly once (src/compiler/inline-variables.ts:if (info.count !== 1) vars.delete(name)), so the same CSS with default options is folded away before the runtime sees it. Declare the token twice — a base value plus aprefers-color-schemeoverride, which is the ordinary shape of themed CSS — and it skips the inliner and reaches the resolver.Measured on
main, all three with the same class:maininlineVariables: false{}{}— inlined away{}@media (prefers-color-scheme: dark) { .themed { --a: 10px } }, compiler defaults{}The third row is the point: no compiler option changed, no exotic input, just a theme token and a cycle someone did not notice.
Cycles split across an ancestor/descendant pair reach it too, because inherited variables resolve through the same function — and that is the shape the tests use, since a single-definition variable is folded away before the runtime sees it.
Root cause — the guard was dead code
varResolverread its guard out ofoptionswith a default, and never wrote it back:resolvecloses over the sameoptionsobject, whose.variableHistorystaysundefined, so every nestedvarResolverallocated a new empty Set. There are exactly fourvariableHistoryreferences insrc/onmain— the optional field onResolveValueOptions, and the destructure /.has/.addabove. Nothing anywhere assigns it.A second hole sits beside it: the
if (name in variables)early return recurses before.addis reached. That is the branch a descendant takes, and therefore the branch the recursion runs through, so even a working set would have been bypassed.Fix
Two changes, both in
varResolver.options—const namesBeingResolved = (options.namesBeingResolved ??= new Set<string>())— so every nested resolve below shares it.finally. Thename in variablesbranch moves inside thetry.Both halves are load-bearing, and each is pinned from its own side. Registering the name cuts the cycle. Removing it per frame — rather than emptying the stack — is what keeps a name readable again once its own resolution has finished, which a name read twice within ONE declaration needs (
box-shadow: var(--c) 1px 1px, var(--c) 2px 2px).Only the within-one-declaration case depends on it.
applyDeclarationsbuilds a fresh options object at each of its threeresolveValuecall sites incalculate-props.ts— the transform, delayed and plain arms — so two declarations never share a stack in the first place. Measured: hoisting a single shared options object across every declaration leaves the whole suite at the exact baseline,1058 passed / 3 failed. So thefinallyis not there to un-block a second declaration; it is there so that one declaration's second read, and one value's second branch, are not mistaken for re-entry.The field is renamed
variableHistory→namesBeingResolved, matching the local it feeds and what it holds.ResolveValueOptionsis internal tosrc/native/styles/and the field is re-exported from no entry point.The diff looks larger than it is — a good part of it is the four existing lookup tiers moving one indentation level into the
try, unchanged.Which plane
Native runtime (
src/native/styles/variables.ts), alone.varResolveris referenced only fromsrc/native/styles/resolve.ts, and there is no variable resolution anywhere undersrc/web— on web the CSS is served to the browser and the cycle rule above is the browser's to implement. So there is no web mirror to write.Tests
10 cases in
src/__tests__/native/variables.test.tsx,describe("circular variables"). 6 of them fail withRangeError: Maximum call stack size exceededagainst the unfixedvarResolver— that is the reproduction. Substitutingmain'svarResolverback in under these tests gives1052 passed, 9 failedagainst this branch's1058 passed, 3 failed— 6 new reds plus the 3 pre-existing Windows failures below. The 6 are the four census rows and both public entry points.test.eachbehind a not-empty sentinel: a variable whose value is itself, a variable reached again through a fallback, two variables that name each other, and one name re-entered from two branches of a single value.useUnstableNativeVariable, andVariableContextProvider, whose value type admits avar()reference.maintoo: a variable read twice in ONE declaration (a two-shadowbox-shadow) is not mistaken for a cycle, and a long non-circular chain still resolves..child { --z: var(--z); width: var(--z) }with--zdeclared once never reaches the runtime, soflattenVar'sseenset is what stops it. That guard decides whether the runtime guard is reached at all, and until now it had no test anywhere.No test asserts a throw. Every case asserts a successful render, so the crash is proven red-to-green rather than pinned as a
toThrow.Every row is falsifiable
A census row asserting only
toStrictEqual({})cannot tell "the cycle was cut" from "resolution no longer works" — an empty style is what both produce. So every row reads a non-cyclic--unrelatedbeside the cycle, and a row can only pass while resolution still works. Every name in a cycle is also declared twice, because a variable declared exactly once is substituted into its readers and never reaches the runtime resolver these rows exist to exercise.Measured by mutating the guard and running the full suite. Baseline is
3 failed— the pre-existing Windows-onlybabel-plugin-testercases, below.varResolverreturnsundefinedunconditionally — everyvar()in the library deadresolve(fallback)instead of nothinga variable reached again through a fallback,{ color: "blue", opacity: 0.5 }against{ opacity: 0.5 }finallyempties the stack (clear()) instead of popping one frameRangeErrorfinallypops nothingbox-shadowflattenVar'sseenset removedThe third and fourth rows pin the
finallyfrom both sides: removing too little reddens thebox-shadow, removing too much reddens the two-branch row, and neither mutation alone reaches the other's test.Suite
numRuntimeErrorTestSuites: 0.main(f70c402) is1048 passed, 1072 totalon the same machine, so this is +10 tests and no new failures. The 3 arereact-native › plugin › 7,react-native-web › plugin › 6and› 17—babel-plugin-testercases over an unrewritten relativerequire("../View"), which fail identically at every ref on Windows. That count is stable on a warm cache; a cold or loaded run adds a tail of first-in-file 5000ms timeouts that are not this branch's either.yarn typecheckandyarn lintexit 0.KNOWN LIMITS
The cut is not always the spec's outcome — sometimes the property keeps a truncated value. The cut returns
undefined, andresolveValue's descriptor-array branch filtersundefinedout of the array and keeps the surviving siblings, so a cycle that is only part of a larger value leaves the rest behind rather than invalidating the declaration. Measured on this branch:--p: 1px var(--p);width: var(--p){ width: [1] }widthunset--a: var(--b) var(--c), both naming--a;color: var(--a){ color: [] }colorunset--t1/--t2cyclic;transform: translateX(var(--t1)){ transform: [{}] }transformunset--bwcyclic;border: var(--bw) solid red{ borderStyle: "solid", borderColor: "red" }The two-branch census row pins the second of these (
{ color: [], opacity: 0.5 }) so the shape is at least recorded rather than incidental. Every one of them is a bounded, renderable value instead of a crash, which is the change this PR is claiming; making them unset is a separate change to howresolveValuetreats a missing piece of a descriptor array, and it would move values that have nothing to do with cycles.I have not verified whether
color: []/width: [1]/transform: [{}]are tolerated or fatal in React Native's own layer — the measurements above are jest, not a device.The guard bounds cycles only. A non-circular chain resolves to great depth, but a long enough one still exhausts the JS stack and throws
RangeError. The guard neither helps nor hurts there — a chain never re-enters a name, so it never reaches the cut — and this PR does not claim to fix it. The ceiling is a property of the JS stack rather than a constant this library owns, so I have deliberately not written a number down; the test pins that a long chain resolves, not how long.A cyclic variable swallows its reader's fallback:
var(--cyclic, blue)yields{}where CSS saysblue. This is pre-existing and not cycle-specific.varResolver's first arm is presence-keyed —if (name in variables) return resolve(variables[name])— so any inherited name that is present and resolves to nothing takes the reader's fallback with it. Measured on this branch: a declared-but-unresolvable non-cyclic variable swallows the fallback, a cyclic one swallows it, and a never-declared name correctly takes it. Those aremain's results too — this PR moves that arm one indentation level into thetryand changes nothing else about it. #431 documents this exact shape in its own body, but its fix is at the two record builders that plant a key holdingundefined; it does not touch that early return, so landing #431 will not fix the CSS-declared case.The keyframes boundary is an open question, not a fix.
shorthands/animation.tsre-enterscalculatePropswith a fresh options object, so the resolution stack does not cross into a keyframe pass. Three attempts to construct a cycle that is reachable through it all rendered cleanly — the animation name resolves and its frame pops before the keyframe pass runs, leaving no live frame to re-enter. With no reproduction I have not written a fix; there is a comment marking the boundary so the next person starts from what is known.Nothing warns. A stylesheet with an accidental cycle silently loses a declaration where it previously lost the screen. That is strictly better, but if you would like a dev-mode warning at the cut point it is a two-line addition and I will add it.
The compile-time guard and this one remain two guards.
flattenVar'sseenset and this resolution stack solve the same problem at different times, and neither knows about the other. Unifying them is not possible as things stand — the compiler can only see cycles inside the properties it is allowed to fold — so this is a note rather than a plan. Both now have a test.Overlaps with open PRs, measured with a three-way
git merge-fileof each PR head against the shared basef70c402:#412 (
fix/non-inheriting-custom-properties) conflicts, and the substantive risk is larger than the textual one. It adds two rungs tosrc/native/styles/variables.ts— anonInheritedVariablesgate around therootVariableslookup, and a newregisteredInitialValueslookup after it — in exactly the region this PR re-indents into itstry. One conflict hunk, resolvable by hand in a minute. What a hand-merge must get right is that both of #412's rungs land INSIDE thetry, alongside the four existing tiers. Land them after thefinallyand that tier resolves outside the resolution stack, unguarded, with no test on either branch that would notice.#431 (
fix/vars-undefined-key) conflicts trivially, insrc/__tests__/native/variables.test.tsxand nowhere else: both PRs add an import fromreact-native-css/nativeat the top of the file, this one foruseUnstableNativeVariableandVariableContextProvider, #431 forVariableContextProvideralone. One hunk, one merged import statement.#413 (
fix/single-definition-inliner-scope) and #389 (fix/scale-percentage) auto-merge clean today. #413 sharessrc/__tests__/native/variables.test.tsxandsrc/compiler/inline-variables.ts, #389 sharessrc/native/styles/resolve.ts; all three files merge without a conflict.#413 is also related in substance, in a way that helps: it scopes the single-definition inliner to its declaring block, which means more custom properties survive to the runtime resolver. Landing it without this one widens the surface on which the crash is reachable.